#!/bin/python2

import os, sys, re, shlex, types
from distutils.version import LooseVersion

import libmisc, libinotify
misc = libmisc.Misc()


class Database(object):
    ''' Get information about packages '''
    def __init__(self):
        self.ROOT_DIR = '/'
        self.CACHE_DIR = '/var/cache/spm'
        self.REMOTE_CACHE = {}
        self.LOCAL_DIR = self.ROOT_DIR + 'var/local/spm'
        self.LOCAL_CACHE = {}
        self.IGNORE = []
        self._notifiers_setup()

    def _notifiers_setup(self):
        # FIXME: should class variables be asigned? non-globals tend to get destroyed
        wm = libinotify.WatchManager()  # Watch Manager
        mask = libinotify.IN_DELETE | libinotify.IN_CREATE | libinotify.IN_MODIFY # watched events
        notifier = libinotify.AsyncNotifier(wm, self._build_remote_cache)
        wdd = wm.add_watch(os.path.join(self.CACHE_DIR, 'repositories'), mask, rec=True)
        notifier2 = libinotify.AsyncNotifier(wm, self._build_local_cache)
        wdd2 = wm.add_watch(self.LOCAL_DIR, mask, rec=True)

    def _build_local_cache(self, event=None):
        # event is only for compat with the notifier
        self.LOCAL_CACHE = {}
        for sdir in misc.list_dirs(self.LOCAL_DIR):
            metadata = os.path.join(sdir, 'metadata')
            footprint = os.path.join(sdir, 'footprint')
            if os.path.isfile(metadata) and os.path.isfile(footprint):
                self.LOCAL_CACHE[sdir] = {
                    'version': self._local_metadata(metadata, 'version'),
                    'description': self._local_metadata(metadata, 'description'),
                    'depends': self._local_metadata(metadata, 'depends'),
                    'features': self._local_metadata(metadata, 'features'),
                    'size': self._local_metadata(metadata, 'size'),
                    'footprint': misc.file_read(footprint)
                }
        # print(sys.getsizeof(self.LOCAL_CACHE))

    def _build_remote_cache(self, event=None):
        # event is only for compat with the notifier
        self.REMOTE_CACHE = {}
        for sdir in misc.list_dirs(os.path.join(self.CACHE_DIR, 'repositories')):
            srcbuild = os.path.join(sdir, 'SRCBUILD')
            if os.path.isfile(srcbuild):
                parser = SRCBUILD(srcbuild)
                self.REMOTE_CACHE[sdir] = {
                    'version': parser.version,
                    'description': parser.description,
                    'depends': parser.depends,
                    'makedepends': parser.makedepends,
                    'checkdepends': parser.checkdepends,
                    'features': parser.features,
                    'sources': parser.sources,
                    'options': parser.options,
                    'backup': parser.backup
                }
        # print(sys.getsizeof(self.REMOTE_CACHE))

    def _local_metadata(self, smetadata, skey):
        ''' Returns metadata of local target '''
        misc.typecheck(smetadata, (types.StringTypes))
        misc.typecheck(skey, (types.StringTypes))

        value = ''
        for line in misc.file_readlines(smetadata):
            if line.startswith(skey + '='):
                value = line.split('=')[1].strip()
        if skey == 'depends' or skey == 'features':
            return value.split()
        return value

    def _parse(self, data):
        ''' Parse features into tuple groups '''
        misc.typecheck(data, (types.StringTypes))

        regex = '([^\[|\|]+)(?:\[([^\|]+)\])?'
        return re.findall(regex, data)

    def remote_all(self, basename=False):
        ''' Returns directories of all remote (repository) targets '''
        misc.typecheck(basename, (types.BooleanType))

        # rebuild cache on demand
        if not self.REMOTE_CACHE:
            self._build_remote_cache()

        if basename:
            lremote = []
            for target in self.REMOTE_CACHE.keys():
                lremote.append(os.path.basename(target))
            return sorted(lremote)
        return sorted(self.REMOTE_CACHE.keys())

    def local_all(self, basename=False):
        ''' Returns directories of all local (installed) targets '''
        misc.typecheck(basename, (types.BooleanType))

        # rebuild cache on demand
        if not self.LOCAL_CACHE:
            self._build_local_cache()

        if basename:
            llocal = []
            for target in self.LOCAL_CACHE.keys():
                llocal.append(os.path.basename(target))
            return sorted(llocal)
        return sorted(self.LOCAL_CACHE.keys())

    def local_search(self, target):
        ''' Returns full path to directory matching target '''
        misc.typecheck(target, (types.StringTypes))

        for ltarget in self.local_all():
            if ltarget == target \
                or os.path.basename(ltarget) == os.path.basename(target):
                return ltarget
        return None

    def remote_search(self, target):
        ''' Returns full path to directory matching target '''
        misc.typecheck(target, (types.StringTypes))

        if os.path.isfile(os.path.join(target, 'SRCBUILD')):
            return target

        for rtarget in self.remote_all():
            if rtarget == target \
                or os.path.basename(rtarget) == os.path.basename(target):
                return rtarget
        return None

    def local_belongs(self, sfile, exact=False, escape=True, ignore=None):
        ''' Searches for match of file in all local targets '''
        misc.typecheck(sfile, (types.StringTypes))
        misc.typecheck(exact, (types.BooleanType))
        misc.typecheck(escape, (types.BooleanType))
        misc.typecheck(ignore, (types.NoneType, types.StringTypes))

        match = []
        # it may not exists if bootstrapping
        if not os.path.isdir(self.LOCAL_DIR):
            return match

        for local in self.local_all(basename=True):
            if local == ignore:
                continue

            if misc.string_search(sfile, self.local_footprint(local), \
                exact=exact, escape=escape):
                match.append(local)
        return match

    def remote_features(self, starget, features=None, ineedle=0):
        ''' Returns all features of target, if features is passed returns only enabled '''
        misc.typecheck(starget, (types.StringTypes))
        misc.typecheck(features, (types.NoneType, types.ListType))
        misc.typecheck(ineedle, (types.IntType))

        lfeatures = []
        for sfeat in self.remote_metadata(starget, 'features'):
            choosen = False
            matches = self._parse(sfeat)
            for match in matches:
                # if features are set and there are multiple choices pick only one
                if features and not choosen and match[0] in features:
                    lfeatures.append(match[ineedle])
                    choosen = True
                # else add everything but only if no features at all are requested
                elif not features:
                    lfeatures.append(match[ineedle])
        return lfeatures

    def remote_mdepends(self, target, features=None, checked=None, cdepends=False):
        ''' Returns missing build dependencies of target, features dependencies
        are calculated from  the frontend as they are configurable and there
        this library is not ment to handle configuration '''
        misc.typecheck(target, (types.StringTypes))
        misc.typecheck(features, (types.NoneType, types.ListType))
        misc.typecheck(checked, (types.NoneType, types.ListType))
        misc.typecheck(cdepends, (types.BooleanType))

        depends = self.remote_metadata(target, 'depends')
        makedepends = self.remote_metadata(target, 'makedepends')
        checkdepends = self.remote_metadata(target, 'checkdepends')

        missing = []
        build_depends = []
        if checked is None:
            checked = []
        if features is None:
            features = []

        # respect ignored targets to avoid building
        # dependencies when not needed
        if target in self.IGNORE:
            return missing

        # depends, {make,check}depends are optional
        if depends:
            build_depends.extend(depends)
        if makedepends:
            build_depends.extend(makedepends)
        if cdepends and checkdepends:
            build_depends.extend(checkdepends)

        for dependency in build_depends:
            matches = self._parse(dependency)
            for match in matches:
                actualdep = match[0]
                # FIXME: this can cause targets to be rebuild always in case
                # the dependency (actualdep) has alternative features and the
                # match requests the feature that the dependency was not build
                # with
                features.extend(self.remote_features(actualdep, match[1].split()))

            if checked and actualdep in checked:
                continue

            if not actualdep in missing \
                and not self.local_uptodate(actualdep, features):
                checked.append(target)
                missing.extend(self.remote_mdepends(actualdep, features, checked, cdepends))
                missing.append(actualdep)
                checked.append(actualdep)
        return missing

    def local_rdepends(self, target, indirect=False, checked=None):
        ''' Returns reverse dependencies of target '''
        misc.typecheck(target, (types.StringTypes))
        misc.typecheck(indirect, (types.BooleanType))
        misc.typecheck(target, (types.NoneType, types.StringTypes))

        reverse = []
        if checked is None:
            checked = []

        basename = os.path.basename(target)
        for installed in self.local_all(basename=True):
            # respect ignored targets
            if installed in self.IGNORE:
                continue

            if checked and installed in checked:
                continue

            if basename in self.local_metadata(installed, 'depends'):
                checked.append(basename)
                if indirect:
                    reverse.extend(self.local_rdepends(installed, \
                        True, checked))
                reverse.append(installed)
                checked.append(installed)
        return reverse

    def local_footprint(self, target):
        ''' Returns files of target '''
        misc.typecheck(target, (types.StringTypes))

        match = self.local_search(target)
        if match:
            return self.LOCAL_CACHE[match]['footprint']

    def local_metadata(self, target, key):
        ''' Returns metadata of local target '''
        misc.typecheck(target, (types.StringTypes))
        misc.typecheck(key, (types.StringTypes))

        match = self.local_search(target)
        if match:
            return self.LOCAL_CACHE[match][key]

    def local_uptodate(self, target, features):
        ''' Returns True if target is up-to-date and False otherwise '''
        misc.typecheck(target, (types.StringTypes))
        misc.typecheck(features, (types.ListType))

        # check if target is installed at all first
        if not self.local_search(target):
            return False

        # if remote target is passed and it's a directory not a base name
        # then the local target will be invalid and local_version will equal
        # None, thus we use the base name to find the local target version
        local_version = self.local_metadata(os.path.basename(target), 'version')
        remote_version = self.remote_metadata(target, 'version')

        # LooseVersion does not handle None which may be the case on invalid
        # remote target so handle that
        if not remote_version:
            return False

        if LooseVersion(local_version) < LooseVersion(remote_version):
            return False

        local_features = self.local_metadata(os.path.basename(target), 'features')
        remote_features = self.remote_features(target, features)
        # check for new feature
        for feature in features:
            if feature in remote_features and not feature in local_features:
                return False

        # check for old feature
        for feature in local_features:
            if not feature in features:
                return False

        return True

    def remote_metadata(self, target, key):
        ''' Returns metadata of remote target '''
        misc.typecheck(target, (types.StringTypes))
        misc.typecheck(key, (types.StringTypes))

        if os.path.isfile(os.path.join(target, 'SRCBUILD')):
            return getattr(SRCBUILD(os.path.join(target, 'SRCBUILD')), key)
        match = self.remote_search(target)
        if match:
            return self.REMOTE_CACHE[match][key]
        # for API consistency due to caching this icky code is here
        elif key == 'version' or key == 'description':
            return ''
        else:
            return []

    def remote_aliases(self, basename=True):
        ''' Returns basename of all aliases '''
        misc.typecheck(basename, (types.BooleanType))

        aliases = []
        for sfile in misc.list_files(os.path.join(self.CACHE_DIR, \
            'repositories')):
            if sfile.endswith('.alias'):
                if basename:
                    aliases.append(misc.file_name(sfile))
                else:
                    aliases.append(misc.file_name(sfile, False))
        return sorted(aliases)

    def remote_alias(self, target):
        ''' Returns alias for target, if not returns original '''
        misc.typecheck(target, (types.StringTypes))

        for alias in self.remote_aliases(basename=False):
            if os.path.basename(target) == os.path.basename(alias):
                return misc.file_readlines(alias + '.alias')
        # return consistent data
        return [target]

    def remote_groups(self, basename=True):
        ''' Returns groups in the repositories '''
        misc.typecheck(basename, (types.BooleanType))

        groups = []
        for sdir in misc.list_dirs(os.path.join(self.CACHE_DIR, \
            'repositories')):
            if not os.path.isfile(os.path.join(sdir, 'SRCBUILD')) \
                and not '/.git/' in sdir and not sdir.endswith('.git'):
                if basename:
                    groups.append(os.path.basename(sdir))
                else:
                    groups.append(sdir)
        return sorted(groups)


class SRCBUILD(object):
    ''' A SRCBUILD parser '''
    _symbol_regex = re.compile(r'\$(?P<name>{[\w\d_]+}|[\w\d]+)')

    def __init__(self, name=None, fileobj=None):
        self.version = ''
        self.description = ''
        self.depends = []
        self.makedepends = []
        self.checkdepends = []
        self.features = []
        self.sources = []
        self.options = []
        self.backup = []

        # Symbol lookup table
        self._var_map = {
            'version': 'version',
            'description': 'description',
            'depends': 'depends',
            'makedepends': 'makedepends',
            'checkdepends': 'checkdepends',
            'features': 'features',
            'sources': 'sources',
            'options': 'options',
            'backup': 'backup',
        }
        # Symbol table
        self._symbols = {}

        if not name and not fileobj:
            raise ValueError('nothing to open')
        should_close = False
        if not fileobj:
            fileobj = open(name, 'r')
            should_close = True
        self._parse(fileobj)
        if should_close:
            fileobj.close()

    def _handle_assign(self, token):
        ''' Expand non-standard variable as Bash does '''
        var, equals, value = token.strip().partition('=')
        # Is it an array?
        if value[0] == '(' and value[-1] == ')':
            self._symbols[var] = self._clean_array(value)
        else:
            self._symbols[var] = self._clean(value)

    def _parse(self, fileobj):
        ''' Parse SRCBUILD '''
        if hasattr(fileobj, 'seek'):
            fileobj.seek(0)
        parser = shlex.shlex(fileobj, posix=True)
        parser.whitespace_split = True
        in_function = False
        while 1:
            token = parser.get_token()
            if token is None or token == '':
                break
            # Skip escaped newlines and functions
            if token == '\n' or in_function:
                continue
            # Special case:
            # Array elements are dispersed among tokens, we have to join
            # them first
            if token.find('=(') >= 0 and not token.rfind(')') >= 0:
                in_array = True
                elements = []
                while in_array:
                    _token = parser.get_token()
                    if _token == '\n':
                        continue
                    if _token[-1] == ')':
                        _token = '"%s")' % _token.strip(')')
                        token = token.replace('=(', '=("', 1) + '"'
                        token = ' '.join((token, ' '.join(elements), _token))
                        in_array = False
                    else:
                        elements.append('"%s"' % _token.strip())
            # Assignment
            if re.match(r'^[\w\d_]+=', token):
                self._handle_assign(token)
            # Function definitions
            elif token == '{':
                in_function = True
            elif token == '}' and in_function:
                in_function = False
        self._substitute()
        self._assign_local()

    def _clean(self, value):
        ''' Pythonize a bash string '''
        return ' '.join(shlex.split(value))

    def _clean_array(self, value):
        ''' Pythonize a bash array '''
        return filter(None, shlex.split(value.strip('()')))

    def _replace_symbol(self, matchobj):
        ''' Replace a regex-matched variable with its value '''
        symbol = matchobj.group('name').strip('{}')
        # If the symbol isn't found fallback to an empty string, like bash
        try:
            value = self._symbols[symbol]
        except KeyError:
            value = ''
        # BUG: Might result in an infinite loop, oops!
        return self._symbol_regex.sub(self._replace_symbol, value)

    def _substitute(self):
        ''' Substitute all bash variables within values with their values '''
        for symbol in self._symbols:
            value = self._symbols[symbol]
            # FIXME: This is icky
            if isinstance(value, str):
                result = self._symbol_regex.sub(self._replace_symbol, value)
            else:
                result = [self._symbol_regex.sub(self._replace_symbol, x) \
                    for x in value]
            self._symbols[symbol] = result

    def _assign_local(self):
        ''' Assign values from _symbols to SRCBUILD variables '''
        for var in self._symbols:
            value = self._symbols[var]
            if var in self._var_map:
                var = self._var_map[var]
            setattr(self, var, value)
